Add secure iframe mode: opaque-origin sandbox + SCORM postMessage bridge#80
Draft
erseco wants to merge 45 commits into
Draft
Add secure iframe mode: opaque-origin sandbox + SCORM postMessage bridge#80erseco wants to merge 45 commits into
erseco wants to merge 45 commits into
Conversation
Codecov Report❌ Patch coverage is
📢 Thoughts on this report? Let us know! |
…ice-worker limitation
…ermissions-Policy
… secure->legacy (watchdog)
…DEC-0059 Route A)
… not the site origin
…g, content_headers, bridge re-extract)
…load fails On a host whose service worker cannot serve an opaque-origin iframe (e.g. the PHP-WASM Moodle Playground), the token URL falls through to a 404 and the in-iframe shim never announces 'ready'. The relay watchdog already reveals the "blocked by security configuration" notice instead of degrading to same-origin, but it waited a flat 8s, during which the iframe sat blank with no notice -- so secure mode "looked like it worked" for several seconds. The watchdog now reacts to the iframe element's 'load' event (which fires even when the navigation ends in an error page such as that 404) and grants only a short grace (~2.5s) for the handshake; if 'load' never fires it still falls back to the 8s cap. The load listener attaches immediately if the iframe is present, otherwise on DOMContentLoaded (the relay is injected inline before the iframe element). Verified empirically in the Playground via Chrome DevTools: the iframe is built in secure mode (opaque, sandbox without allow-same-origin), tokenpluginfile returns 404, and the notice is shown with the iframe hidden -- the token does not help there because the blocker is the service worker, not the cookie (DEC-0060). Vitest 52/52 (two new tests cover the load-driven fast path).
The Playground's PHP-WASM service worker cannot serve an opaque-origin iframe, so secure mode there only ever shows the "blocked by security configuration" notice. Set iframemode=legacy in the blueprint setConfigs so the preview actually renders the package and is useful. This is a Playground-only override applied at boot; real Moodle keeps the secure default.
…e the iframe Defense in depth for secure mode: the package is served via tokenpluginfile with an executable CSP, so opening the token URL top-level (e.g. in a new tab) would run author JS as Moodle's origin. Add a `sandbox allow-scripts allow-popups allow-forms` CSP directive (secure mode + HTML only, via content_headers) so the document keeps an opaque origin however it is loaded. Tokens mirror the secure iframe sandbox; the SCORM postMessage bridge is unaffected because the iframe is already opaque. Unit test asserts the directive is present in the secure CSP. Raised while reviewing the sibling omeka-s-exelearning secure-iframe work; applied here for consistency across the eXeLearning embedders.
In secure mode the package runs in an opaque-origin sandbox, which leaves YouTube/Vimeo players and PDFs blank (the sandbox flag propagates to nested iframes; Chrome also blocks its PDF viewer without allow-same-origin). Promote those embeds to the trusted parent: a shim baked into the package replaces whitelisted-video / .pdf iframes with placeholders and reports their geometry via postMessage; an inline relay on the activity page validates + rebuilds the URL and overlays the real player inline over each placeholder. - js/exe_embed_shim.js: in-iframe, self-activates only in the opaque origin (dormant in legacy); dual-export for Vitest. - js/exe_embed_relay.js: parent-side validate (host whitelist + canonical URL rebuild + same-origin package-file invariant for PDFs) and inline overlay. - package_manager + scorm_injector: bake the shim + whitelist into every page head, alongside (and independent of) the SCORM bridge. - player_iframe::embed_whitelist(); relay inlined in view.php (secure only). PDFs: local package PDFs always render; any https .pdf renders; same-origin PDFs must belong to this package (served as application/pdf, never executable). Tests: Vitest validator/promote (exe_embed.test.js) + SCORM coexistence guard (embed_scorm_coexistence.test.js); scorm_injector_test asserts the shim is baked without dropping the SCORM bridge. Documented in DEC-0061. Fixtures under research/fixtures/elpx/.
mod_exelearning serves package assets with relative URLs (unlike the wp/omeka proxies, which rewrite to absolute), so a locally-packaged PDF was reported to the parent relay as a relative src. The relay resolves URLs against the host page, not the content, so it rejected the relative path and the local PDF did not render. The shim runs inside the content, so it now resolves each src against the content location and reports the ABSOLUTE URL. Verified live (secure mode): the embeds demo renders 4 players inline (YouTube, Vimeo, remote PDF, local package PDF). Adds a Vitest regression test (a relative src is reported absolute). Updates DEC-0061 with the live results, including the SCORM scoring validation (track.php saved a full attempt; gradebook + attempts report reflect it) confirming the embed shim does not break scoring.
- Playwright/Firefox e2e (playwright-embed.config.cjs + tests/e2e/): loads the real shim + relay against an opaque-origin sandboxed harness and asserts a whitelisted YouTube embed and a RELATIVE local PDF are promoted to inline parent players while a non-whitelisted iframe is not. Proves the promote-to-parent mechanism works in Firefox, not just Chromium. Run with `npm run test:e2e:embed`. - Vitest: add coverage for relay makePlayer() (video vs PDF attributes) and shim collect() (geometry report). The browser-bootstrap paths (init/report/observer) are covered by the e2e. - README: document external embeds in Secure mode (whitelist + PDF policy + how to run the tests).
…tch gate
- CodeQL (high): the e2e spec asserted a non-whitelisted host was absent with
src.includes('example.com') ("incomplete URL substring sanitization"). Rewrite the
assertions to parse exact hostnames (new URL().hostname) and an anchored regex for the
canonical YouTube URL, so no URL is checked by substring.
- Codecov patch: cover the relay's createRelay()/onMessage -> overlay player path and
the instance validate() in Vitest, and mark the browser-only bootstrap of both the
shim (init/report/observer) and the relay (init/pingAll/scheduleReflow) as v8-ignore
(they require a framed, opaque-origin window and are exercised by the Firefox e2e, not
happy-dom). exe_embed_relay.js 95% / exe_embed_shim.js 84% line coverage; 77 unit tests.
…dow first The vendored pipwerks API.get() started its lookup at win.parent and skipped the current window. In secure (opaque-origin) mode the SCORM API is provided locally as window.API by the in-iframe bridge shim (DEC-0059) and the Moodle parent is a cross-origin/opaque frame, so reaching into parent.API threw SecurityError, init() never activated the connection, and every LMSSetValue/LMSCommit became a silent no-op -- no attempt rows were ever written. Legacy (same-origin) mode masked it because the parent there hosts the API. Restore the standard pipwerks order: try find(window) first, then fall back to the parent and opener, with every cross-origin hop wrapped so an opaque ancestor can never abort the lookup. Legacy keeps working: with no local API, find(window) walks up to the same-origin parent exactly as before. Add tests/js/scorm_api_wrapper.test.js (loads the vendored wrapper with a controllable window) covering current-window-first, opaque-parent safety, the null fallback, the legacy walk-up, and the full init()/set() save path. Document the root cause and the DEC-0059 verification gap in DEC-0062.
…eometry
Extend the external-embed allowlist with Dailymotion (www/geo.dailymotion.com,
/embed/video/{id}) and EducaMadrid/Mediateca de Madrid (mediateca.educa.madrid.org,
/video/{id}/fs), each with a per-provider canonical-URL validator in the relay so
only a reconstructed, known-good embed URL is ever rendered. Clamp the relayed
player overlay to the placeholder's rect (defence in depth against geometry-driven
clickjacking; the overlay already clips with overflow:hidden). Mark the shim/relay
as the canonical source for the wp/omeka mirrors. Refresh the demo fixture and the
Vitest + Firefox e2e coverage with the new hosts and their reject cases.
The in-iframe shim restarts its embed-id counter on every page, so after the content navigates (e.g. eXe multi-page next/prev), the new page's first embed reuses id exe-embed-1. The relay reused the existing player for that id and only repositioned it, never updating its src -- so the previous page's video (e.g. YouTube) lingered on the next page, stretched to the new box. Tag each player with the URL it renders (data-exe-embed-src) and, in sync(), replace the player when a reused id now maps to a different URL instead of repositioning the stale one. Add a regression test covering a reused id that navigates from YouTube to Vimeo.
Update DEC-0061 with the 2026-06-14 work: the necessity re-validation against a real eXe export (machinery confirmed required on four axes, not assumed), the added Dailymotion + EducaMadrid providers, the sandbox-token alignment (allow-forms in the iframe attribute AND the response-level CSP sandbox directive), and the page-navigation lingering-embed fix. Add tools/check-embed-sync.mjs: a local maintenance helper that verifies the shim/relay logic + whitelist hosts + sandbox tokens stay in sync across the three embedders (mod canonical, wp/omeka mirrors). It is not a CI gate (no shared infra); the new header comments in the shim/relay point to it.
…-0061) The interactive-video iDevice with remote sources (YouTube/EducaMadrid) drives the YouTube IFrame Player API to pause and overlay timed questions; in the opaque sandbox the player cannot render in the iframe, and promoting it to the parent decouples it from the iDevice's control. A player-side control bridge was prototyped (video played, questions fired) but reverted: reconstructing the iDevice's cover/float/slide layout from the parent is fragile. Record the decision -- remote interactive-video needs legacy mode; local-file source works in secure -- and that the proper fix belongs upstream in the eXe iDevice (detect the opaque origin and degrade, or drive a parent relay itself).
…st allowlist Replace the maintained host allowlist with a structural invariant (DEC-0061): in the default 'open' mode the relay promotes any iframe whose src is https AND cross-origin to the LMS, rejecting same-origin, sub/superdomains of the LMS, IP/loopback/local hosts and userinfo. The host is irrelevant to escape -- a cross-origin sandboxed player is isolated from the LMS by the same-origin policy -- so the allowlist only mitigated phishing/ tracking, which the sandboxed content can already do. An admin setting 'embedmode' (open|strict, fail-safe to strict) keeps the allowlist + per-provider reconstruction for high-security deployments. This supports any provider with no maintenance and supersedes the earlier "no whitelist is unsafe" conclusion (correct only for Moodle's same-origin model, not this cross-origin sandboxed one). Security conditions (verified): - The video player is now SANDBOXED: allow-scripts allow-same-origin allow-popups allow-forms allow-presentation, with NO allow-top-navigation/allow-modals -- so an arbitrary embed cannot redirect the tab. allow-same-origin sits on the cross-origin player (the provider keeps its own origin, never the LMS's) and is what lets it be sandboxed; it is not the forbidden content-iframe case. - D1: a load-time guard removes a player that lands same-origin to the LMS (a cross-origin URL that 30x-redirects to this origin would otherwise be scriptable with allow-same-origin). - D2: promoted players are tagged data-exe-embed-player and excluded from frameForSource/ pingAll, so a sandboxed player cannot forge a sync message and impersonate a content source. - The shim promotes any cross-origin https / .pdf candidate; the relay is the authoritative gate. PDFs stay unsandboxed (the browser PDF viewer fails inside a sandbox), unchanged. Rewrite the Vitest suite (open + strict + IP/subdomain helpers + sandbox + forged-message defence), update the Firefox e2e (open mode: every cross-origin/PDF iframe promoted, players sandboxed), update tools/check-embed-sync.mjs invariants, and document it in DEC-0061.
…eanups Quality-only cleanups (no behaviour change): - scorm_injector: drop the dead window.__exeEmbedWhitelist global, collapse the three <head>-insertion blocks into a table-driven loop, and build the script payloads once per file from one $libs prefix instead of six root/.. pairs. - view.php: extract an inline-module emit helper and only ship the embed whitelist in strict mode (open mode ignores it). - admin styles: extract the duplicated action_link() into a shared styles_action_button helper. - JS: reuse armBlockedTimer in the watchdog; move scorm_tracker's snapshot to the async-only XHR path; hoist getBoundingClientRect out of sync()'s per-embed loop.
- Embed relay/shim: normalize a trailing-dot FQDN-root host so the served host in 'host.' form can no longer slip past the open-mode cross-origin gate and be promoted as a player. - SCORM bridge relay: reject a forged track when the expected nonce is empty, and require a non-null iframe contentWindow (no null===null match). - exelearning_pluginfile: emit Referrer-Policy: no-referrer and X-Content-Type-Options: nosniff on every secure-mode package file so the in-URL file token cannot leak via Referer and a .pdf path cannot smuggle executable HTML. - Tests: trailing-dot, nonce-empty and null-contentWindow regression cases; content_headers per-file header assertions; check-embed-sync records normalizeHost as a relay invariant.
| // Report an ABSOLUTE url: the shim runs inside the content, so resolve the | ||
| // (possibly relative) src against the content location. The parent relay | ||
| // cannot — it would resolve a relative url against the host page instead. | ||
| var absoluteUrl = src; |
Brings in the xAPI/ADL research (DEC-0063, renumbered from DEC-0059 to avoid collision with this branch's DEC-0059..0062) and main CI updates. docs/indices conflicts resolved by regenerating with build_indexes.py.
Brings in the research/ cleanup (retire arquitectura/+inventario/, reconcile ADR/task states, refresh sources) and the unreferenced-fixtures removal (~13MB). Clean auto-merge; indices regenerated. DEC-0059..0063 coexist (no ID collision).
Brings in the recorded maintainer decisions (xAPI endpoint design DEC-0063, DEC-0033 scope, etc.). Conflict in status.yaml RIE-001 resolved by keeping this branch's estado: mitigado (the secure-iframe work DEC-0059..0062 actually implements the hardening) over main's estado: aceptado; the TAREA-013 note lives in the merged TODO.md. Indices regenerated.
Resolve view.php conflict between the secure-iframe SCORM bridge (DEC-0059/
DEC-0060/DEC-0062) and the xAPI ingestion layer (DEC-0064), which both
rewrite the SCORM-tracker bootstrap.
Resolution: keep the secure/legacy split (bridge relay + embed relay in secure
mode, inline scorm_tracker in legacy mode) and fold in the xAPI path, but gate
xAPI-primary to legacy mode:
$emitsxapi = !$securemode && exelearning_xapi_primary_enabled()
&& exelearning_package_emits_xapi(...)
Rationale: js/xapi_listener.js trusts a statement by event.origin === host
origin. In secure mode the package runs in an opaque origin (event.origin is
'null'), so the listener can never receive its statements. There the SCORM
bridge relay (window-identity + nonce) is the working channel, so the SCORM shim
must stay LIVE; setting disableTracking there would record zero grades (the
DEC-0062 failure secure mode exists to fix). disableTracking and the shared
$sessiontoken/xAPI registration therefore only take effect in legacy mode.
Bridging xAPI over the secure relay is left as a follow-up.
…-0065) The xAPI ingestion layer (DEC-0064) assumed the package iframe is served same-origin: js/xapi_listener.js trusts a statement by event.origin === host origin. The secure iframe mode (DEC-0059/0060) serves the package from an opaque origin where event.origin is the string "null", so that check can never match and every statement is dropped. The branch merge had gated xAPI off in secure mode as a stopgap; this enables it properly. - js/xapi_listener.js: add a window-identity trust mode. When configured with iframeid (or expectedSource for tests) the listener trusts a statement by event.source === the package iframe's contentWindow (resolved lazily) and ignores the opaque "null" origin, exactly like the SCORM bridge relay. Legacy same-origin keeps the event.origin check. - js/scorm_bridge_relay.js: add disableTracking. When set, the relay still validates the SCORM message and still runs the ready handshake + watchdog, but forwards no track.php POST. The decision lives on the trusted parent (fresh each load), not the baked-in shim, so it holds even for packages extracted before the flag; the opaque shim cannot reach track.php itself (no sesskey). - view.php: xAPI-primary now applies in both iframe modes (drop the !secure gate). Secure passes disableTracking to the relay and injects the listener with iframeid; legacy injects it with allowedOrigin. - Tests (Vitest 115/115): window-identity accept/reject + precedence, lazy resolution; relay disableTracking suppresses the POST while ready still handshakes. - Docs: ADR DEC-0065 (+ adrs/diario indices, diario entry); English tracking-architecture.md / xapi-integration-plan.md; QA checklist scenarios for secure-mode grading and the kill-switch-mid-session limitation. Reviewed adversarially (6 lenses, verified): no new double-grading or grade-loss path; the kill-switch mid-session divergence is a pre-existing DEC-0064 property (documented), not introduced here.
A multi-agent adversarial review of the branch found no critical/high bugs;
these are the 3 confirmed low-severity items.
- scorm_bridge_shim.js: isSandboxedOpaque() treated ANY web-storage exception as
"opaque", so in legacy (same-origin) mode a QuotaExceededError or a disabled-
storage policy would wrongly activate the baked shim — which posts scores to a
parent that has no relay listener, silently losing the grade. Narrow the
secondary probe to a genuine SecurityError (the only opaque-origin signal).
- exe_embed_relay.js: a promoted CROSS-ORIGIN PDF was overlaid unsandboxed, so a
package embedding https://attacker/x.pdf that serves HTML could top-navigate the
Moodle tab to a phishing page (the exact thing the video player blocks). Sandbox
cross-origin PDFs without allow-top-navigation/allow-scripts; same-origin package
PDFs (served application/pdf+nosniff) stay unsandboxed so the viewer renders.
Fixes the stale makePlayer doc-comment that already claimed the PDF was sandboxed.
- lang/{es,ca,eu,gl}: add the 9 secure-iframe/embed strings (embedmode*, iframemode*,
securemodeblocked) that existed only in lang/en, restoring 5-language parity for
the admin settings and the student-facing blocked notice (~ pending-review prefix).
Tests: Vitest 117/117 (new: non-SecurityError storage must not count as opaque;
cross-origin PDF is sandboxed without top-nav; same-origin PDF stays unsandboxed).
…imeo)
Compare the secure YouTube/Vimeo embed approach of mod_exelearning (DEC-0061,
promote-to-parent inline overlay) against three siblings: procomún
(fix/apertura-segura-elpx, click→modal), the eXeLearning editor
(fix/opaque-iframe-external-media, producer-side {provider,videoId}+MessagePort
channel), and the security paper (the SoK that names the model).
All four share the opaque-origin + promote-to-parent + SOP-isolation model;
they differ by layer and trust channel. Verdict (role-dependent) + a concrete
recommendation for the plugin (keep the relay; borrow eXe's id-only channel;
make interactive-video complementary; add Vimeo canonicalization).
Adds AN-015 + REPO-010 (procomún) + REPO-011 (paper) source entries and the
notas/repos indices.
Reconcile teacher-mode handling with main (#86). main retired the host-side CSS injection in favour of the package's own ?exe-teacher=1 URL parameter, so: - view.php: keep the secure/legacy iframe-mode resolution and append ?exe-teacher=1 to the resolved $iframeurl when teachermodevisible is on. The parameter works in secure (opaque-origin) mode too because the package reads its own location.search, so no host CSS injection is needed in either mode. - Drop the exelearning_require_teacher_mode_hider() call (the helper and classes/local/ui/teacher_mode_hider.php were removed on main). - Retire the SCORM bridge's teacher-mode path, now redundant: remove teachermodevisible from the relay config ($relaycfg), the relay handshake postMessage and the in-iframe shim's hideTeacherMode() (it injected the exact #teacher-mode-toggler-wrapper CSS that #86 retired). Update tests/js accordingly. SCORM tracking over the bridge is unaffected.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Adds a configurable package iframe security mode (
mod_exelearning/iframemode, defaultsecure) that isolates the arbitrary author HTML/JS of an
.elpxfrom the Moodlepage. In secure mode the package runs in an opaque-origin sandboxed iframe, is
served via
tokenpluginfile.php(so its assets load without the session cookie),and SCORM scoring is relayed to Moodle over a validated
postMessagebridge.Legacy keeps the previous same-origin behaviour as an opt-in fallback. Secure mode
is never silently downgraded: where it cannot render, a notice is shown instead.
Implements the Tier 2 roadmap of DEC-0019 (DEC-0059) and the verified-correct serving
mechanism (DEC-0060, which corrects DEC-0059's "Route A").
Problem
The iframe used
allow-scripts allow-same-origin, so package JS could read the parentDOM/cookies and forge authenticated requests with the
sesskey(RIE-001). Isolatingsame-origin authenticated content from the parent with the
sandboxattribute alone isimpossible: with
allow-same-originthe content reaches the parent; without it, theopaque document never sends the
SameSite=Laxsession cookie, so its subresources(CSS/JS) 404 from
pluginfile.php.Technical approach
allow-same-originand servesthe package through
tokenpluginfile.php(make_pluginfile_urltoken in the URLpath, a short-TTL
core_filesuser key). Relative subresources inherit the token andload without the session cookie.
tokenpluginfilestill runs the componentcapability check (
exelearning_pluginfile→require_capability('mod/exelearning:view')).window.APIshim (js/scorm_bridge_shim.js,reusing
js/scorm_tracker.js) buffers CMI, resolves objectids from its own DOM, andposts deltas to the parent. The parent relay (
js/scorm_bridge_relay.js) validates bywindow identity (
event.source === iframe.contentWindow), a closed action list and aper-view nonce, then performs the authenticated
track.phpPOST (and asendBeaconflush on
pagehide). Thesesskeynever crosses the bridge.exelearning_pluginfile()in secure mode:object-src 'none',base-uri 'none',frame-ancestors 'self', andconnect-srcpinned to this site (limits token exfiltration). Inline + eval scripts stay allowed
(eXeLearning needs them).
can't serve an opaque iframe, like a PHP-WASM playground), the shim never signals
readyand a client-side watchdog reveals a "blocked by security configuration"notice (
securemodeblocked) instead of falling back to legacy. The watchdog reacts tothe iframe element's
loadevent (which fires even when the navigation ends in anerror page such as a 404) plus a short grace, so the notice appears right after the
failed load rather than behind a fixed long wait. Legacy is opt-in.
in-iframe storage polyfill prevents opaque-origin
SecurityErrorin shipped enginescripts (exe_atools, exe_export, checklist, edicuatex).
Security considerations
No
allow-same-origin, noallow-popups-to-escape-sandbox, noallow-top-navigation,no
allow-modals. The content receives only a read-only file token (not thesesskey) → strictly safer than legacy. Window-identity + nonce + closed-actionvalidation on the bridge; no generic exec surface;
track.php/track::ingestre-validates and clamps server-side. Residual: the token is visible in the iframe URL
(read-only, short TTL,
connect-src-contained); a strict CSP toggle that also blocksexternal img/script exfil is left as follow-up.
SCORM compatibility
pipwerks resolves
window.APIviapipwerks.SCORM.API.get(). The vendored wrapper'sget()had been altered to look only inwindow.parent, skipping the current window— fine in legacy (the parent hosts the API) but fatal in secure: the opaque parent throws
SecurityError, soinit()never activated and every score was silently dropped.This branch restores the standard order (current window first; parent/opener as guarded
fallbacks), so the iframe-local shim API is found without touching the cross-origin parent.
LMSGetValueanswers from a local cache; objectid routing (DEC-0017) and theform/scrambled save guard (DEC-0042) are preserved. Covered by
tests/js/scorm_api_wrapper.test.jsand verified live (attempt rows written, rawscore100). See DEC-0062.
Moodle precedents investigated
Moodle's own H5P uses
includetoken/tokenpluginfilewhen embedded and validatespostMessageby source+context (h5p/classes/player.php,h5p/js/embed.js); mod_scormwalks
window.parentfor the API with no sandbox; SimplePie is the only coresandboxuse. No core precedent exists for a cross-origin/opaque sandbox of authenticated content
served from a separate origin (stated plainly); that is left as future infra (DEC-0019
Route B).
Configuration changes
New global setting
mod_exelearning/iframemode(securedefault /legacy) pluslanguage strings. No per-activity field; no DB/upgrade/backup changes.
Tests added (incl. security)
tests/player_iframe_test.php: mode resolution (fail-safe to secure),sandbox tokens per mode (secure has no
allow-same-origin/allow-top-navigation/allow-modals/allow-popups-to-escape-sandbox), the CSP directives (object-src none,base-uri none, frame-ancestors self, connect-src pinned, no bare
https:inconnect-src) and the Permissions-Policy.
tests/js/scorm_bridge.test.js: the relay rejects forged messages(wrong window source, wrong nonce, bad shape, unknown action), the storage polyfill,
the handshake/queueing, and the watchdog (shows the blocked notice when
readynever arrives; cleared when secure renders; the load-driven fast path arms the short
grace timer on the iframe
load, not the long fallback).Verification
Verified in Chrome DevTools against a real Moodle: opaque token-served iframe
(
tokenpluginfile.php/.../index.html, originnull), CSS/JS load, shim postsready,relay validates,
track.phpsaved a grade ({ok:true, rawscore:100, peritem:{1:100}}),watchdog does not false-fire. Also verified in the PHP-WASM Playground: the iframe is
built in secure mode (opaque, no
allow-same-origin),tokenpluginfilereturns 404(the service worker does not control opaque subframes, so the URL falls through — the
token cannot help here; the blocker is the SW, not the cookie), the shim never readies,
and the notice is shown with the iframe hidden, confirming the no-downgrade
behaviour.
phpcs --standard=moodle0/0; Vitest 52/52; PHPUnit suite 319/319. FullPHPUnit matrix + Behat run in CI.
Backward compatibility
legacymode is byte-for-byte the previous behaviour (opt-in). Existing activitiesself-heal the injected bridge on next view.
Limitations / follow-up
PHP-WASM / service-worker hosts (e.g. the Moodle Playground preview) cannot serve an
opaque-origin iframe — the service worker does not control opaque subframes, so the
token URL 404s — and therefore correctly show the secure-mode notice (now right after
the failed load) instead of rendering the package. This is expected: the Playground is a
preview host; real Moodle serves secure normally. Set
iframemode=legacy(or theblueprint config) if you want the Playground demo to render the package. Maximum
isolation (serving from a separate origin / subdomain) needs infrastructure outside the
plugin (DEC-0019 Route B). A strict-CSP admin toggle is a possible follow-up.
Moodle Playground Preview
The changes in this pull request can be previewed and tested using a Moodle Playground instance.
ℹ️ The eXeLearning editor is fetched from the shared release and unpacked into the plugin when the playground boots, so the first load may take a few extra seconds. ELPX upload, viewer and preview work normally.
## External embeds in secure mode (DEC-0061)Secure mode's opaque sandbox also left YouTube/Vimeo players and PDFs blank (the
sandbox flag propagates to the nested player iframe; Chrome also blocks its PDF viewer
without
allow-same-origin). This branch makes them work standalone, inline, with nosubdomain, by promoting whitelisted embeds to the trusted parent:
js/exe_embed_shim.js, injected alongside the SCORMbridge; self-activates only in the opaque origin, dormant in legacy) replaces
whitelisted-video /
.pdfiframes with placeholders and reports their geometry viapostMessage.js/exe_embed_relay.js) validates + rebuilds thecanonical URL (host whitelist, reject userinfo, id pattern) and overlays the real
player inline over each placeholder.
https.pdfrenders; a same-origin.pdfmust belong to this package (served bytokenpluginfileasapplication/pdf),so it can never be an executable same-origin route.
SCORM scoring is unaffected. The embed shim is independent of the bridge (different
postMessagetype), the injector still bakes the SCORM bridge + pipwerks wrapper, and acoexistence test (
tests/js/embed_scorm_coexistence.test.js) guards that the bridgestill accepts scores with the embed modules loaded. Tests:
tests/js/exe_embed.test.js(validator + promotion),
scorm_injector_test(shim baked without dropping the bridge);the live container PHPUnit run (
track_test+ grades, 119 OK) confirms scoring saves.Documented in DEC-0061. Test fixtures under
research/fixtures/elpx/.Providers + paging fix. The relay also recognises Dailymotion and
EducaMadrid/Mediateca de Madrid (per-provider canonical-URL validators), clamps the
overlay to the placeholder box (clickjacking defence in depth), and replaces the overlay
when the eXe content pages to another view — the in-iframe shim restarts its embed-id
counter per page, so a reused id previously kept the prior page's video. Regression test
in
exe_embed.test.js;tools/check-embed-sync.mjsguards drift vs the wp/omeka mirrors.Verified live on mod, wp and omeka with a real multi-page export (DEC-0062).
Update — open by default, no host list (DEC-0061 §6.3). The host allowlist is replaced by a
structural invariant: in the default
openmode the relay promotes any iframe whose src ishttps + cross-origin to Moodle (rejecting same-origin, sub/superdomains, IP/loopback/local hosts
and userinfo). A cross-origin sandboxed player is isolated from Moodle by the same-origin policy,
so the host is irrelevant to escape — the allowlist only mitigated phishing/tracking (which the
sandboxed content can already do). An admin setting
embedmode(open|strict, fail-safe to strict)keeps the allowlist + per-provider reconstruction for high-security sites. The promoted video player
is now sandboxed (
allow-scripts allow-same-origin allow-popups allow-forms allow-presentation,no top-navigation/modals) so an arbitrary embed cannot redirect the tab; a load-time guard removes any
embed that lands same-origin (redirect-laundering); promoted players are excluded from message auth
(cannot impersonate the content). PDFs stay unsandboxed (the browser PDF viewer fails inside a
sandbox). Supports any provider with zero maintenance, and reverses the earlier "no whitelist is
unsafe" note (correct only for Moodle's same-origin model, not this cross-origin sandboxed one).